From 4a9bf575bd24b2e24348c65d9fbbf9c81a73cb35 Mon Sep 17 00:00:00 2001 From: Sebastien Castiel Date: Tue, 30 Jan 2024 16:36:29 -0500 Subject: Create expense from receipt (#69) * Create expense from receipt * Add modal * Update README --- .../expenses/create-from-receipt-button-actions.ts | 48 ++++ .../expenses/create-from-receipt-button.tsx | 278 +++++++++++++++++++++ src/app/groups/[groupId]/expenses/expense-list.tsx | 11 +- src/app/groups/[groupId]/expenses/page.tsx | 13 +- 4 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts create mode 100644 src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx (limited to 'src/app/groups/[groupId]') diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts b/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts new file mode 100644 index 0000000..1714f82 --- /dev/null +++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts @@ -0,0 +1,48 @@ +'use server' +import { getCategories } from '@/lib/api' +import { env } from '@/lib/env' +import OpenAI from 'openai' + +const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY }) + +export async function extractExpenseInformationFromImage(imageUrl: string) { + 'use server' + const categories = await getCategories() + + const body = { + model: 'gpt-4-vision-preview', + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: ` + This image contains a receipt. + Read the total amount and store it as a non-formatted number without any other text or currency. + Then guess the category for this receipt amoung the following categories and store its ID: ${categories.map( + ({ id, grouping, name }) => `"${grouping}/${name}" (ID: ${id})`, + )}. + Guess the expense’s date and store it as yyyy-mm-dd. + Guess a title for the expense. + Return the amount, the category, the date and the title with just a comma between them, without anything else.`, + }, + ], + }, + { + role: 'user', + content: [{ type: 'image_url', image_url: { url: imageUrl } }], + }, + ], + } + const completion = await openai.chat.completions.create(body as any) + + const [amountString, categoryId, date, title] = completion.choices + .at(0) + ?.message.content?.split(',') ?? [null, null, null, null] + return { amount: Number(amountString), categoryId, date, title } +} + +export type ReceiptExtractedInfo = Awaited< + ReturnType +> diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx new file mode 100644 index 0000000..5feb931 --- /dev/null +++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx @@ -0,0 +1,278 @@ +'use client' + +import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon' +import { + ReceiptExtractedInfo, + extractExpenseInformationFromImage, +} from '@/app/groups/[groupId]/expenses/create-from-receipt-button-actions' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '@/components/ui/drawer' +import { ToastAction } from '@/components/ui/toast' +import { useToast } from '@/components/ui/use-toast' +import { useMediaQuery } from '@/lib/hooks' +import { formatExpenseDate } from '@/lib/utils' +import { Category } from '@prisma/client' +import { ChevronRight, Loader2, Receipt } from 'lucide-react' +import { getImageData, useS3Upload } from 'next-s3-upload' +import Image from 'next/image' +import { useRouter } from 'next/navigation' +import { PropsWithChildren, ReactNode, useState } from 'react' + +type Props = { + groupId: string + groupCurrency: string + categories: Category[] +} + +export function CreateFromReceiptButton({ + groupId, + groupCurrency, + categories, +}: Props) { + const [pending, setPending] = useState(false) + const { uploadToS3, FileInput, openFileDialog } = useS3Upload() + const { toast } = useToast() + const router = useRouter() + const [receiptInfo, setReceiptInfo] = useState< + | null + | (ReceiptExtractedInfo & { url: string; width?: number; height?: number }) + >(null) + const isDesktop = useMediaQuery('(min-width: 640px)') + + const handleFileChange = async (file: File) => { + const upload = async () => { + try { + setPending(true) + console.log('Uploading image…') + let { url } = await uploadToS3(file) + console.log('Extracting information from receipt…') + const { amount, categoryId, date, title } = + await extractExpenseInformationFromImage(url) + const { width, height } = await getImageData(file) + setReceiptInfo({ amount, categoryId, date, title, url, width, height }) + } catch (err) { + console.error(err) + toast({ + title: 'Error while uploading document', + description: + 'Something wrong happened when uploading the document. Please retry later or select a different file.', + variant: 'destructive', + action: ( + upload()}> + Retry + + ), + }) + } finally { + setPending(false) + } + } + upload() + } + + const receiptInfoCategory = + (receiptInfo?.categoryId && + categories.find((c) => String(c.id) === receiptInfo.categoryId)) || + null + + const DialogOrDrawer = isDesktop + ? CreateFromReceiptDialog + : CreateFromReceiptDrawer + + return ( + + + + } + title={ + <> + Create from receipt + + Beta + + + } + description={<>Extract the expense information from a receipt photo.} + > +
+

+ Upload the photo of a receipt, and we’ll scan it to extract the + expense information if we can. +

+
+ +
+ +
+ Title: +
{receiptInfo?.title ?? '…'}
+
+
+ Category: +
+ {receiptInfoCategory ? ( +
+ + {receiptInfoCategory.grouping} + + {receiptInfoCategory.name} +
+ ) : ( + '' || '…' + )} +
+
+
+ Amount: +
+ {receiptInfo?.amount ? ( + <> + {groupCurrency} {receiptInfo.amount.toFixed(2)} + + ) : ( + '…' + )} +
+
+
+ Date: +
+ {receiptInfo?.date + ? formatExpenseDate( + new Date(`${receiptInfo?.date}T12:00:00.000Z`), + ) + : '…'} +
+
+
+
+

You’ll be able to edit the expense information after creating it.

+
+ +
+
+
+ ) +} + +function CreateFromReceiptDialog({ + trigger, + title, + description, + children, +}: PropsWithChildren<{ + trigger: ReactNode + title: ReactNode + description: ReactNode +}>) { + return ( + + {trigger} + + + {title} + + {description} + + + {children} + + + ) +} + +function CreateFromReceiptDrawer({ + trigger, + title, + description, + children, +}: PropsWithChildren<{ + trigger: ReactNode + title: ReactNode + description: ReactNode +}>) { + return ( + + {trigger} + + + {title} + + {description} + + +
{children}
+
+
+ ) +} diff --git a/src/app/groups/[groupId]/expenses/expense-list.tsx b/src/app/groups/[groupId]/expenses/expense-list.tsx index 0dc7a7e..a97c2bd 100644 --- a/src/app/groups/[groupId]/expenses/expense-list.tsx +++ b/src/app/groups/[groupId]/expenses/expense-list.tsx @@ -3,7 +3,7 @@ import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon' import { Button } from '@/components/ui/button' import { SearchBar } from '@/components/ui/search-bar' import { getGroupExpenses } from '@/lib/api' -import { cn } from '@/lib/utils' +import { cn, formatExpenseDate } from '@/lib/utils' import { Expense, Participant } from '@prisma/client' import dayjs, { type Dayjs } from 'dayjs' import { ChevronRight } from 'lucide-react' @@ -159,7 +159,7 @@ export function ExpenseList({ {currency} {(expense.amount / 100).toFixed(2)}
- {formatDate(expense.expenseDate)} + {formatExpenseDate(expense.expenseDate)}
+ {env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT && ( + + )}